all repos — weimar @ 8dcf6e79e30fa40a7f7da17169314b250793210c

Unnamed repository; edit this file 'description' to name the repository.

web/src/routes/image/[id]/+page.svelte (view raw)

  1<script lang="ts">
  2	import { onMount } from 'svelte';
  3	import { getImage, addTags, removeTags, suggestTags, imageDownloadUrl, type ImageDetail } from '$lib/api';
  4
  5	let { params } = $props();
  6	let id = $derived(parseInt(params.id));
  7
  8	let img = $state<ImageDetail | null>(null);
  9	let loading = $state(true);
 10	let error = $state('');
 11
 12	let tagInput = $state('');
 13	let suggestions = $state<string[]>([]);
 14	let showSuggestions = $state(false);
 15	let tagError = $state('');
 16
 17	onMount(async () => {
 18		try {
 19			img = await getImage(id);
 20		} catch (err) {
 21			error = err instanceof Error ? err.message : 'Failed to load image';
 22		} finally {
 23			loading = false;
 24		}
 25	});
 26
 27	function formatSize(bytes: number): string {
 28		if (bytes < 1024) return bytes + ' B';
 29		if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
 30		return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
 31	}
 32
 33	function formatDate(s: string): string {
 34		return new Date(s).toLocaleString();
 35	}
 36
 37	async function onTagInput() {
 38		const q = tagInput.split(',').pop()?.trim() || '';
 39		if (q.length < 1) {
 40			suggestions = [];
 41			showSuggestions = false;
 42			return;
 43		}
 44		try {
 45			const result = await suggestTags(q);
 46			suggestions = result.map((t) => t.tag);
 47			showSuggestions = suggestions.length > 0;
 48		} catch {
 49			suggestions = [];
 50			showSuggestions = false;
 51		}
 52	}
 53
 54	function selectSuggestion(s: string) {
 55		const parts = tagInput.split(',').slice(0, -1);
 56		parts.push(s);
 57		tagInput = parts.join(', ') + ', ';
 58		showSuggestions = false;
 59	}
 60
 61	async function handleAddTags() {
 62		if (!img) return;
 63		const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
 64		if (tags.length === 0) return;
 65		tagError = '';
 66		try {
 67			const result = await addTags(img.id, tags);
 68			img.tags_detail = result.tags_detail;
 69			img.tags = result.tags_detail.map((t) => t.tag);
 70			tagInput = '';
 71		} catch (err) {
 72			tagError = err instanceof Error ? err.message : 'Failed to add tags';
 73		}
 74	}
 75
 76	async function handleRemoveTags() {
 77		if (!img) return;
 78		const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
 79		if (tags.length === 0) return;
 80		tagError = '';
 81		try {
 82			const result = await removeTags(img.id, tags);
 83			img.tags_detail = result.tags_detail;
 84			img.tags = result.tags_detail.map((t) => t.tag);
 85			tagInput = '';
 86		} catch (err) {
 87			tagError = err instanceof Error ? err.message : 'Failed to remove tags';
 88		}
 89	}
 90</script>
 91
 92{#if loading}
 93	<p class="state-msg">Loading...</p>
 94{:else if error}
 95	<p class="state-msg error">{error}</p>
 96{:else if img}
 97	<div class="page-head">
 98		<h1>{img.filename}</h1>
 99		<div class="head-line"></div>
100	</div>
101
102	<div class="layout">
103		<div class="preview">
104			{#if img.thumbnail_path}
105				<img src={imageDownloadUrl(img.id)} alt={img.filename} />
106			{:else}
107				<div class="no-thumb">NO THUMBNAIL</div>
108			{/if}
109		</div>
110
111		<div class="meta">
112			<div class="meta-block uploader-block">
113				<span class="meta-label">UPLOADED BY</span>
114				<div class="uploader-row">
115					{#if img.avatar_url}
116						<img src={img.avatar_url} alt="" class="avatar-img" />
117					{/if}
118					<a href="/browse?uploader={encodeURIComponent(img.uploader_username)}" class="meta-value uploader-link">{img.uploader_username}</a>
119				</div>
120			</div>
121			<div class="meta-block">
122				<span class="meta-label">ID</span>
123				<span class="meta-value">#{img.id}</span>
124			</div>
125			<div class="meta-block">
126				<span class="meta-label">DIMENSIONS</span>
127				<span class="meta-value">{img.width} &times; {img.height}</span>
128			</div>
129			<div class="meta-block">
130				<span class="meta-label">FILE SIZE</span>
131				<span class="meta-value">{formatSize(img.file_size)}</span>
132			</div>
133			<div class="meta-block">
134				<span class="meta-label">TYPE</span>
135				<span class="meta-value">{img.mime_type}</span>
136			</div>
137			<div class="meta-block">
138				<span class="meta-label">UPLOADED</span>
139				<span class="meta-value">{formatDate(img.created_at)}</span>
140			</div>
141			<div class="meta-block tags-block">
142				<span class="meta-label">TAGS</span>
143				<div class="tag-pills">
144					{#if img.tags_detail.length > 0 || (img.innate_tags && img.innate_tags.length > 0)}
145						{#each img.tags_detail as t (t.tag)}
146							<a href="/browse?tag={encodeURIComponent(t.tag)}" class="tag-pill" title="Used {t.usage_count} time{t.usage_count === 1 ? '' : 's'}">
147								<span class="tag-name">{t.tag}</span>
148								<span class="tag-count">{t.usage_count}</span>
149							</a>
150						{/each}
151						{#if img.innate_tags}
152							{#each img.innate_tags as t (t)}
153								<a href="/browse?tag={encodeURIComponent(t)}" class="tag-pill innate">
154									<span class="tag-name">{t}</span>
155								</a>
156							{/each}
157						{/if}
158					{:else}
159						<span class="none">none</span>
160					{/if}
161				</div>
162			</div>
163
164			{#if img.is_owner}
165				<div class="meta-block edit-tags-block">
166					<span class="meta-label">EDIT TAGS</span>
167					<div class="tag-input-row">
168						<div class="tag-wrapper">
169							<input type="text" placeholder="funny, meme, cat" bind:value={tagInput}
170								oninput={onTagInput}
171								onblur={() => setTimeout(() => { showSuggestions = false; }, 200)}
172							/>
173							{#if showSuggestions}
174								<div class="suggestions">
175									{#each suggestions as s}
176										<button type="button" class="sug-btn" onmousedown={() => selectSuggestion(s)}>{s}</button>
177									{/each}
178								</div>
179							{/if}
180						</div>
181						<button class="tag-btn add" onclick={handleAddTags}>ADD</button>
182						<button class="tag-btn remove" onclick={handleRemoveTags}>REMOVE</button>
183					</div>
184					{#if tagError}
185						<span class="tag-err">{tagError}</span>
186					{/if}
187				</div>
188			{/if}
189
190			<a href={imageDownloadUrl(img.id)} class="btn" download={img.filename}>
191				DOWNLOAD ORIGINAL
192			</a>
193		</div>
194	</div>
195
196	<p class="back"><a href="/browse">&larr; BACK TO BROWSE</a></p>
197{/if}
198
199<style>
200	.state-msg {
201		padding: 2rem 0;
202		font-size: 1.1rem;
203		color: #888;
204	}
205	.error {
206		color: #c62828;
207	}
208
209	.page-head {
210		margin-bottom: 1.5rem;
211	}
212	.page-head h1 {
213		word-break: break-all;
214	}
215	.head-line {
216		height: 3px;
217		background: #c62828;
218		width: 3rem;
219		margin-top: 0.4rem;
220	}
221
222	.layout {
223		display: flex;
224		gap: 2rem;
225		flex-wrap: wrap;
226	}
227	.preview {
228		flex: 1;
229		min-width: 300px;
230		background: #1a1a1a;
231		display: flex;
232		align-items: center;
233		justify-content: center;
234	}
235	.preview img {
236		display: block;
237		width: 100%;
238		height: auto;
239		max-height: 70vh;
240		object-fit: contain;
241	}
242	.no-thumb {
243		padding: 4rem 2rem;
244		color: #555;
245		font-family: 'Oswald', sans-serif;
246		font-weight: 500;
247		font-size: 0.9rem;
248		letter-spacing: 0.12em;
249	}
250
251	.meta {
252		width: 260px;
253		flex-shrink: 0;
254		display: flex;
255		flex-direction: column;
256		gap: 1rem;
257	}
258	.meta-block {
259		display: flex;
260		flex-direction: column;
261		gap: 0.15rem;
262	}
263	.meta-label {
264		font-family: 'Oswald', sans-serif;
265		font-weight: 500;
266		font-size: 0.7rem;
267		letter-spacing: 0.15em;
268		color: #999;
269	}
270	.meta-value {
271		font-family: 'Inter', sans-serif;
272		font-size: 0.9rem;
273		font-weight: 600;
274		color: #1a1a1a;
275	}
276
277	.uploader-row {
278		display: flex;
279		align-items: center;
280		gap: 0.4rem;
281	}
282	.uploader-link {
283		color: #1a1a1a;
284		text-decoration: none;
285	}
286	.uploader-link:hover {
287		color: #c62828;
288		text-decoration: underline;
289	}
290
291	.avatar-img {
292		width: 1.5rem;
293		height: 1.5rem;
294		object-fit: cover;
295		flex-shrink: 0;
296	}
297
298	.tags-block {
299		flex: 1;
300	}
301	.tag-pills {
302		display: flex;
303		flex-wrap: wrap;
304		gap: 0.35rem;
305	}
306	.tag-pill {
307		display: inline-flex;
308		align-items: center;
309		gap: 0.3rem;
310		background: #1a1a1a;
311		padding: 0.25rem 0.5rem 0.25rem 0.65rem;
312		font-family: 'Inter', sans-serif;
313		font-size: 0.78rem;
314		font-weight: 500;
315		color: #eee;
316		text-decoration: none;
317		transition: background 0.15s;
318	}
319	.tag-pill:hover {
320		background: #c62828;
321		text-decoration: none;
322	}
323	.tag-name {
324		font-weight: 600;
325	}
326	.tag-count {
327		background: rgba(255, 255, 255, 0.12);
328		padding: 0.05rem 0.35rem;
329		font-size: 0.68rem;
330		font-weight: 600;
331		color: rgba(255, 255, 255, 0.65);
332		min-width: 1.1rem;
333		text-align: center;
334	}
335	.tag-pill:hover .tag-count {
336		background: rgba(0, 0, 0, 0.2);
337	}
338	.tag-pill.innate {
339		background: #1565c0;
340	}
341	.tag-pill.innate:hover {
342		background: #1976d2;
343		text-decoration: none;
344	}
345	.tag-pill.innate .tag-count {
346		display: none;
347	}
348	.none {
349		color: #bbb;
350		font-size: 0.85rem;
351	}
352
353	.btn {
354		display: inline-flex;
355		align-items: center;
356		align-self: flex-start;
357		padding: 0.55rem 1.25rem;
358		background: #c62828;
359		color: #fff;
360		border: none;
361		font-family: 'Oswald', sans-serif;
362		font-weight: 500;
363		font-size: 0.8rem;
364		letter-spacing: 0.1em;
365		text-decoration: none;
366		text-transform: uppercase;
367		cursor: pointer;
368		transition: background 0.15s;
369		margin-top: 0.5rem;
370	}
371	.btn:hover {
372		background: #b71c1c;
373		text-decoration: none;
374	}
375
376	.back {
377		margin-top: 2rem;
378	}
379	.back a {
380		font-family: 'Oswald', sans-serif;
381		font-weight: 500;
382		font-size: 0.85rem;
383		letter-spacing: 0.1em;
384	}
385
386	.edit-tags-block {
387		border-top: 2px solid #1a1a1a;
388		padding-top: 0.75rem;
389	}
390	.tag-input-row {
391		display: flex;
392		gap: 0.35rem;
393		align-items: flex-start;
394	}
395	.tag-wrapper {
396		flex: 1;
397		position: relative;
398		min-width: 0;
399	}
400	.tag-wrapper input[type="text"] {
401		width: 100%;
402		padding: 0.4rem 0.5rem;
403		border: 2px solid #1a1a1a;
404		border-radius: 0;
405		font-family: 'Inter', sans-serif;
406		font-size: 0.78rem;
407		font-weight: 500;
408		background: #fff;
409		box-sizing: border-box;
410	}
411	.tag-wrapper input[type="text"]:focus {
412		outline: none;
413		border-color: #c62828;
414	}
415	.suggestions {
416		position: absolute;
417		top: 100%;
418		left: 0;
419		right: 0;
420		background: #fff;
421		border: 2px solid #1a1a1a;
422		border-top: none;
423		margin: 0;
424		padding: 0;
425		z-index: 10;
426		max-height: 130px;
427		overflow-y: auto;
428	}
429	.sug-btn {
430		display: block;
431		width: 100%;
432		text-align: left;
433		padding: 0.35rem 0.5rem;
434		border: none;
435		background: none;
436		font-family: 'Inter', sans-serif;
437		font-size: 0.78rem;
438		font-weight: 500;
439		cursor: pointer;
440		border-radius: 0;
441	}
442	.sug-btn:hover {
443		background: #f0f0f0;
444	}
445	.tag-btn {
446		padding: 0.4rem 0.65rem;
447		border: none;
448		font-family: 'Oswald', sans-serif;
449		font-weight: 500;
450		font-size: 0.72rem;
451		letter-spacing: 0.08em;
452		cursor: pointer;
453		white-space: nowrap;
454		transition: background 0.15s;
455	}
456	.tag-btn.add {
457		background: #1a1a1a;
458		color: #fff;
459	}
460	.tag-btn.add:hover {
461		background: #2e7d32;
462	}
463	.tag-btn.remove {
464		background: #1a1a1a;
465		color: #fff;
466	}
467	.tag-btn.remove:hover {
468		background: #c62828;
469	}
470	.tag-err {
471		display: block;
472		margin-top: 0.3rem;
473		color: #c62828;
474		font-family: 'Inter', sans-serif;
475		font-size: 0.72rem;
476		font-weight: 600;
477	}
478</style>